Catalyst / admin/Strike 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Strike

public

Web-Based UK Cyber Compliance Tool with Reporting

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Strike / StrikeXi v3 / backend / app / scoring.py 5374 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
"""
StrikeXi scoring engine.

Computes weighted maturity scores from an assessment's answers:
  - Per-question score   = option.score (0-1) * question.weight
  - Per-principle score  = sum(question scores) / sum(weights)   -> 0-1
  - Per-objective score  = weighted mean of its principles       -> 0-100
  - Overall score        = mean of objective scores              -> 0-100

Then it evaluates remediation_mappings: any principle whose normalised
score falls below the mapping threshold queues the linked remediation.
"""
from collections import defaultdict

from sqlalchemy.orm import Session

from . import models


def compute_and_persist(db: Session, assessment: models.Assessment,
                        triggered_by: str = "system", label: str = None) -> dict:
    # Pull answers joined to question + option
    answers = (
        db.query(models.AssessmentAnswer)
        .filter(models.AssessmentAnswer.assessment_id == assessment.id)
        .all()
    )

    q_by_id = {q.id: q for q in db.query(models.Question).all()}
    opt_by_id = {o.id: o for o in db.query(models.AnswerOption).all()}
    principles = {p.id: p for p in db.query(models.CafPrinciple).all()}
    objectives = {o.id: o for o in db.query(models.CafObjective).all()}

    # Accumulate weighted scores per principle
    pr_weighted = defaultdict(float)   # sum(score*weight)
    pr_weight = defaultdict(float)     # sum(weight)

    for a in answers:
        q = q_by_id.get(a.question_id)
        opt = opt_by_id.get(a.option_id)
        if not q or not opt:
            continue
        w = float(q.weight)
        pr_weighted[q.principle_id] += float(opt.score) * w
        pr_weight[q.principle_id] += w

    principle_scores = {}  # principle_id -> normalised 0-1
    for pid in pr_weight:
        principle_scores[pid] = pr_weighted[pid] / pr_weight[pid] if pr_weight[pid] else 0.0

    # Objective scores = mean of contained principle scores (0-100)
    obj_principles = defaultdict(list)
    for pid, score in principle_scores.items():
        pr = principles.get(pid)
        if pr:
            obj_principles[pr.objective_id].append(score)

    objective_scores = {}
    for oid, scores in obj_principles.items():
        objective_scores[oid] = round((sum(scores) / len(scores)) * 100, 2)

    overall = round(sum(objective_scores.values()) / len(objective_scores), 2) if objective_scores else 0.0

    # ---- Persist per-principle scores (replace existing) ----
    db.query(models.AssessmentPrincipleScore).filter(
        models.AssessmentPrincipleScore.assessment_id == assessment.id
    ).delete()
    for pid, score in principle_scores.items():
        db.add(models.AssessmentPrincipleScore(
            assessment_id=assessment.id, principle_id=pid, score=round(score * 100, 2)
        ))

    # ---- Persist objective scores (replace existing) ----
    db.query(models.AssessmentObjectiveScore).filter(
        models.AssessmentObjectiveScore.assessment_id == assessment.id
    ).delete()
    for oid, score in objective_scores.items():
        db.add(models.AssessmentObjectiveScore(
            assessment_id=assessment.id, objective_id=oid, score=score
        ))

    # ---- Evaluate remediation mappings ----
    db.query(models.RemediationAction).filter(
        models.RemediationAction.assessment_id == assessment.id
    ).delete()

    mappings = db.query(models.RemediationMapping).all()
    triggered = 0
    for m in mappings:
        pscore = principle_scores.get(m.principle_id)
        if pscore is None:
            continue
        if pscore < float(m.threshold):
            db.add(models.RemediationAction(
                assessment_id=assessment.id,
                principle_id=m.principle_id,
                remediation_id=m.remediation_id,
                principle_score=round(pscore * 100, 2),
                status="queued",
            ))
            triggered += 1

    assessment.overall_score = overall
    assessment.status = "completed"

    principle_scores_100 = {k: round(v * 100, 2) for k, v in principle_scores.items()}

    # ---- Snapshot an immutable revision (v3) ----
    # Revision 1 = first completion; each re-score appends a new revision so the
    # ORIGINAL scores/answers are always retained for the audit trail and report.
    prev_no = (
        db.query(models.AssessmentRevision)
        .filter(models.AssessmentRevision.assessment_id == assessment.id)
        .count()
    )
    revision_no = prev_no + 1
    snapshot = {
        "overall_score": overall,
        "objective_scores": objective_scores,
        "principle_scores": principle_scores_100,
        "answers": {str(a.question_id): str(a.option_id) for a in answers},
        "answer_count": len(answers),
        "remediations_triggered": triggered,
    }
    db.add(models.AssessmentRevision(
        assessment_id=assessment.id,
        revision_no=revision_no,
        label=label or ("Initial assessment" if revision_no == 1 else f"Re-score {revision_no - 1}"),
        overall_score=overall,
        snapshot=snapshot,
        triggered_by=triggered_by,
    ))

    db.commit()

    return {
        "overall_score": overall,
        "objective_scores": objective_scores,
        "principle_scores": principle_scores_100,
        "remediations_triggered": triggered,
        "revision_no": revision_no,
        "is_rescore": revision_no > 1,
    }